﻿using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Web;
using Newtonsoft.Json;
using FBGraph.Async;

namespace FBGraph
{
    /// <summary>Encapsulates all Graph API functionality.</summary>
    public partial class FBGraphContext : IFBGraphContext
    {
        /// <summary>The base url for Facebook's Graph API.</summary>
        public const String API_URL = "https://graph.facebook.com/";

        /// <summary>Initializes an instance of <see cref="FBGraphContext" />.</summary>
        public FBGraphContext()
        {
            this.Config = new Configuration.FBGraphConfigurationManager();

            this.ClientID = this.Config.ClientID;
            this.ClientSecret = this.Config.ClientSecret;

            this.InitControllers();
        }

        /// <summary>Initializes an instance of <see cref="FBGraphContext" /> with the specified <paramref name="clientID" /> and <paramref name="clientSecret" />.</summary>
        /// <param name="clientID">The Client ID of the application being used.</param>
        /// <param name="clientSecret">The Client Secret of the application being used.</param>
        public FBGraphContext(Int64 clientID, String clientSecret)
        {
            this.Config = new Configuration.FBGraphConfigurationManager();

            this.ClientID = clientID;
            this.ClientSecret = clientSecret;

            this.InitControllers();
        }

        partial void InitControllers();

        /// <summary>Gets the Facebook application's Client ID</summary>
        protected Int64 ClientID { get; private set; }
        Int64 IFBGraphContext.ClientID { get { return this.ClientID; } }

        /// <summary>Gets the Facebook application's Client Secret</summary>
        protected String ClientSecret { get; private set; }
        String IFBGraphContext.ClientSecret { get { return this.ClientSecret; } }

        /// <summary>Gets a reference to a <see cref="FBGraph.Configuration.FBGraphConfigurationManager" /> for reading data from the configuration.</summary>
        public Configuration.FBGraphConfigurationManager Config { get; private set; }

        /// <summary>Gets or sets the set of extended permissions the current user had granted the application.</summary>
        public virtual ExtendedPermission ExtendedPermissions { get; protected set; }

        /// <summary>Gets or sets the <see cref="AccessToken" /> for the current user.</summary>
        public virtual AccessToken? AccessToken { get; private set; }

        /// <summary>Gets a <see cref="Boolean" /> value representing whether the current user has a valid <see cref="AccessToken" /> for your application.</summary>
        public Boolean IsAuthorized { get { return this.AccessToken.HasValue; } }

        /// <summary>Gets the FBGraph User ID of the current user if they have a valid <see cref="AccessToken" />.</summary>
        public virtual Int64? UserID
        {
            get
            {
                if (AccessToken.HasValue) return AccessToken.Value.UserID;
                else return null;
            }
        }

        /// <summary>Allows derived classes to specify logic to be executed before a Graph API request is made.</summary>
        protected virtual void BeforeGraphApiRequest() { }

        /// <summary>Executes a <see cref="HttpWebRequest" /> and returns the resulting object.</summary>
        /// <typeparam name="TResult">The type of object to return.</typeparam>
        /// <param name="request">An <see cref="HttpWebRequest" />.</param>
        /// <returns>A <typeparamref name="TResult" /> object representing the result of the <see cref="HttpWebRequest" />.</returns>
        /// <remarks>
        /// If there is an active <see cref="AsyncContainer" /> on the context, the <see cref="HttpWebRequest" /> will be queued and executed 
        /// asynchronously. The value returned will be a newly instantiated instance of <typeparamref name="TResult" />, which will be
        /// populated once the request completes.
        /// </remarks>
        /// <seealso cref="AsyncContainer" />
        /// <seealso cref="IFBGraphResult" />
        protected virtual TResult Execute<TResult>(FBGraphApiRequest request)
            where TResult : class, IFBGraphResult, new()
        {
            this.BeforeGraphApiRequest();
            if (this.AsyncContainer == null)
            {
                try
                {
                    String content = null;
                    request.Attempts++;
                    using (var response = request.InnerRequest.GetResponse())
                    {
                        using (var reader = new StreamReader(response.GetResponseStream()))
                        {
                            content = reader.ReadToEnd();
                        }
                        var result = JsonConvert.DeserializeObject<TResult>(content);
                        return result;
                    }
                }
                catch (WebException ex)
                {
                    var apiConfig = Config.GetApiConfiguration();
                    var hasRetries = apiConfig.RetryCount - request.Attempts >= 0;

                    if (hasRetries && ex.Status == WebExceptionStatus.Timeout) return Execute<TResult>(request);

                    FBGraphApiException fgex;
                    if (!HandleApiError(ex, out fgex)) throw;
                    else
                    {
                        if (hasRetries && fgex.Type == null) return Execute<TResult>(request);

                        return default(TResult);
                    }
                }
            }
            else
            {
                var value = new TResult();
                this.AsyncContainer.Enqueue(new FBGraphAsyncRequest<TResult>(request, value));
                return value;
            }
        }
        TResult IFBGraphContext.Execute<TResult>(FBGraphApiRequest request) { return this.Execute<TResult>(request); }
        private TResult Execute<TResult>(HttpWebRequest request)
            where TResult : class, IFBGraphResult, new() { return this.Execute<TResult>(new FBGraphApiRequest(request)); }

        /// <summary>Executes a basic HTTP GET operation and deserializes the result into an instance of <typeparamref name="TResult"/>.</summary>
        /// <typeparam name="TResult">The type of object that will be returned.</typeparam>
        /// <param name="uri">The url of the qs to get.</param>
        /// <returns>An instance of <typeparamref name="TModel" /> containing the result of the qs deserialized from JSON.</returns>
        /// <remarks>
        /// <typeparamref name="TResult" /> should account for any collection classes required to contain the resulting objects, such as
        /// an array or <see cref="PagedResult{TModel}" />.
        /// </remarks>
        public TResult Get<TResult>(Uri uri)
            where TResult : class, IFBGraphResult, new()
        {
            var request = (HttpWebRequest)WebRequest.Create(uri);

            var result = Execute<TResult>(request);
            return result;
        }

        /// <summary>Gets the Graph object with the specified <paramref name="id" />.</summary>
        /// <typeparam name="TModel">The type of object that should be returned.</typeparam>
        /// <param name="id">The Graph identifier or alias of the object that should be retrieved.</param>
        /// <param name="fields">An optional array of field names to select instead of the default set of fields for the query.</param>
        /// <returns>A <typeparamref name="TModel" /> object that has been deserialized from the JSON response of the GET request.</returns>
        /// <exception cref="ArgumentNullException"><paramref name="id" /> was null.</exception>
        /// <exception cref="ArgumentException"><paramref name="id" /> was an empty string.</exception>
        protected virtual TModel GetSingle<TModel>(Object id, String[] fields)
            where TModel : class, IFBGraphResult, new()
        {
            if (id == null) throw new ArgumentNullException("id");
            if (id as String == String.Empty) throw new ArgumentException("id may not be an empty string.", "id");

            var qs = new NameValueCollection();
            if (fields != null) qs.Add("fields", String.Join(",", fields));

            var uri = CreateRequestUri(null, qs, id);
            var request = (HttpWebRequest)WebRequest.Create(uri);
            var result = Execute<TModel>(request);
            return result;
        }
        TModel IFBGraphContext.GetSingle<TModel>(Object id, String[] fields) { return this.GetSingle<TModel>(id, fields); }

        /// <summary>Gets the Graph objects with the specified <paramref name="ids" />.</summary>
        /// <typeparam name="TModel">The type of object the identifiers in <paramref name="ids" /> reference.</typeparam>
        /// <param name="fields">An optional array of field names to select instead of the default set of fields for the query.</param>
        /// <param name="ids">One or more Graph identifiers or aliases of the objects that should be retrieved.</param>
        /// <returns>A <see cref="Dictionary{TId, TModel}" /> of <typeparamref name="TModel" /> objects keyed by their identifiers that have been deserialized from the JSON response of the GET request.</returns>        
        /// <exception cref="ArgumentNullException"><paramref name="ids" /> was null.</exception>
        /// <exception cref="ArgumentException"><paramref name="ids" /> contained a null value or empty string.</exception>
        protected virtual DictionaryResult<TModel> GetMany<TModel>(String[] fields, params Object[] ids)
        {
            if (ids == null) throw new ArgumentNullException("ids");
            if (ids.Any(id => id == null)) throw new ArgumentException("ids may not contain null values", "ids");
            if (ids.Any(id => id as String == String.Empty)) throw new ArgumentException("ids may not contain empty strings", "ids");

            var qs = new NameValueCollection();
            if (fields != null) qs.Add("fields", String.Join(",", fields));

            var uri = CreateRequestUri(null, qs, ids);

            var request = (HttpWebRequest)WebRequest.Create(uri);

            var result = Execute<DictionaryResult<TModel>>(request);
            return result;
        }
        DictionaryResult<TModel> IFBGraphContext.GetMany<TModel>(String[] fields, params Object[] ids) { return this.GetMany<TModel>(fields, ids);}

        /// <summary>Gets a collection of objects associated with the specified connection for the object identified by <paramref name="id" />.</summary>
        /// <param name="id">The identifier of the object whose connection is being queried.</param>
        /// <param name="fields">An optional array of field names to select instead of the default set of fields for the query.</param>
        /// <param name="since">The minimum post date/time to filter results by.</param>
        /// <param name="until">The maximum post date/time to filter results by.</param>
        /// <param name="limit">The maximum number of objects to return.</param>
        /// <param name="offset">The index offset from which to start returning results.</param>
        /// <param name="connection">The name of the connection from which to retrieve objects.</param>
        /// <returns>A collection of objects associated with the connection being queried.</returns>
        protected virtual PagedResult<TModel> GetConnection<TModel>(Object id, String connection, String[] fields, DateTime? since, DateTime? until, Int32? limit, Int32? offset)
        {
            if (id == null)
            {
                if (this.IsAuthorized) id = "me";
                else throw new ArgumentNullException("id may not be null unless the user is authenticated.", "id");
            }
            
            var qs = new NameValueCollection();
            if (since.HasValue) qs.Add("since", since.ToString());
            if (until.HasValue) qs.Add("until", until.ToString());
            if (limit.HasValue) qs.Add("limit", limit.ToString());
            if (offset.HasValue) qs.Add("offset", offset.ToString());
            if (fields != null) qs.Add("fields", String.Join(",", fields));

            var uri = CreateRequestUri(connection, qs, id);
            var request = (HttpWebRequest)WebRequest.Create(uri);

            var result = Execute<PagedResult<TModel>>(request);
            return result;
        }
        PagedResult<TModel> IFBGraphContext.GetConnection<TModel>(Object id, String connection, String[] fields, DateTime? since, DateTime? until, Int32? limit, Int32? offset)
        { return this.GetConnection<TModel>(id, connection, fields, since, until, limit, offset); }
        
        /// <summary>Performs a Graph API search for the given query and constraining parameters.</summary>
        /// <typeparam name="TModel">The type of object to return.</typeparam>
        /// <param name="query">The search term or keywords.</param>
        /// <param name="type">The type of search to perform.</param>
        /// <param name="fields">An optional array of field names to select instead of the default set of fields for the query.</param>
        /// <param name="since">The minimum post date/time to filter results by.</param>
        /// <param name="until">The maximum post date/time to filter results by.</param>
        /// <param name="limit">The maximum number of objects to return.</param>
        /// <param name="offset">The index offset from which to start returning results.</param>
        /// <returns>A <see cref="PagedResult{TModel}" /> containing the results of the search query.</returns>
        protected virtual PagedResult<TModel> Search<TModel>(String query, String type, String[] fields, DateTime? since, DateTime? until, Int32? limit, Int32? offset)
        {
            var qs = new NameValueCollection();
            qs.Add("q", query);
            qs.Add("type", type);

            if (since.HasValue) qs.Add("since", since.ToString());
            if (until.HasValue) qs.Add("until", until.ToString());
            if (limit.HasValue) qs.Add("limit", limit.ToString());
            if (offset.HasValue) qs.Add("offset", offset.ToString());
            if (fields != null) qs.Add("fields", String.Join(",", fields));

            var uri = CreateRequestUri("search", qs);
            var request = (HttpWebRequest)WebRequest.Create(uri);

            var result = Execute<PagedResult<TModel>>(request);
            return result;
        }
        PagedResult<TModel> IFBGraphContext.Search<TModel>(String query, String type, String[] fields, DateTime? since, DateTime? until, Int32? limit, Int32? offset) { return this.Search<TModel>(query, type, fields, since, until, limit, offset); }
        
        /// <summary>Creates an object for the specified connection by executing an HTTP POST operation with the specified arguments.</summary>
        /// <param name="id">The target object id for which the connection object is being created.</param>
        /// <param name="connection">The name of the connection where the object is being created.</param>
        /// <param name="arguments">A <see cref="IDictionary{String, Object} " /> of arguments required by the Graph API to complete the operation.</param>
        /// <returns>A <see cref="String" /> or numeric value containing the object id of the object that was created.</returns>
        protected IdResult Post(Object id, String connection, IDictionary<String, Object> arguments)
        {
            if (id == null) id = "me";

            var uri = CreateRequestUri(connection, null, id);
            var request = (HttpWebRequest)WebRequest.Create(uri);
            request.Method = "POST";

            if (arguments != null && arguments.Count > 0)
            {
                var encodedArgs = arguments.Select(p => String.Format("{0}={1}", p.Key, HttpUtility.UrlEncode(p.Value.ToString()))).ToArray();
                using (var writer = new StreamWriter(request.GetRequestStream()))
                {
                    writer.Write(String.Join("&", encodedArgs));
                    writer.Flush();
                }
            }

            var result = Execute<IdResult>(request);
            return result;
        }
        IdResult IFBGraphContext.Post(Object id, String connection, IDictionary<String, Object> arguments) { return this.Post(id, connection, arguments); }

        static readonly String MULTIPART_BOUNDARY_FORMAT = "---------------------------{0}";
        const String FORMDATA_TEMPLATE = "{2}--{3}{2}Content-Disposition: form-data; name=\"{0}\"{2}{2}{1}{2}";
        const String FILEHEADER_TEMPLATE = "{2}--{3}{2}Content-Disposition: form-data; name=\"{0}\"; filename=\"{1}\"{2}Content-Type: {4}{2}{2}";

        private String GetFormData(Object key, Object value, String boundary)
        {
            return String.Format(FORMDATA_TEMPLATE, key, value, Environment.NewLine, boundary);
        }

        /// <summary>Creates an object for the specified connection by uploading binary data using an HTTP POST operation with the specified arguments.</summary>
        /// <param name="id">The target object id for which the connection object is being created.</param>
        /// <param name="connection">The name of the connection where the object is being created.</param>
        /// <param name="arguments">A <see cref="IDictionary{String, Object} " /> of arguments required by the Graph API to complete the operation.</param>
        /// <param name="filename">The filename of the file being uploaded.</param>
        /// <param name="fileparameter">The name of the parameter used to identify the file.</param>        /// 
        /// <param name="contentType">The MIME content type of the file being uploaded.</param>
        /// <param name="data">A <see cref="Byte" /> array containing the file that will be uploaded.</param>
        /// <returns>A <see cref="String" /> or numeric identifier of the object that was created.</returns>
        protected virtual IdResult PostFile(Object id, String connection, IDictionary<String, Object> arguments, String filename, String fileparameter, String contentType, Stream data)
        {
            if (id == null) id = "me";

            var uri = CreateRequestUri(connection, null);

            var fileData = new Byte[data.Length];
            data.Read(fileData, 0, fileData.Length);

            var request = (HttpWebRequest)WebRequest.Create(uri);
            var boundary = String.Format(MULTIPART_BOUNDARY_FORMAT, DateTime.Now.Ticks);
            request.ContentType = String.Format("multipart/form-data; boundary={0}", boundary);
            request.Method = "POST";

            using (var requestStream = request.GetRequestStream())
            {
                using (var writer = new StreamWriter(requestStream))
                {
                    writer.Write(GetFormData("id", id, boundary));
                    foreach (var arg in arguments) writer.Write(GetFormData(arg.Key, arg.Value ?? String.Empty, boundary));

                    writer.Write(FILEHEADER_TEMPLATE, fileparameter, filename, Environment.NewLine, boundary, contentType);
                    writer.Flush();

                    requestStream.Write(fileData, 0, fileData.Length);
                    requestStream.Flush();

                    writer.Write(Environment.NewLine);
                    writer.Write("--");
                    writer.Write(boundary);
                    writer.Write("--");
                    writer.Write(Environment.NewLine);
                    writer.Flush();

                    if (requestStream.CanRead)
                    {
                        using (var reader = new StreamReader(requestStream))
                        {
                            requestStream.Position = 0;
                            var content = reader.ReadToEnd();
                        }
                    }
                }
            }

            var result = Execute<IdResult>(request);
            return result;
        }
        IdResult IFBGraphContext.PostFile(Object id, String connection, IDictionary<String, Object> arguments, String filename, String fileparameter, String contentType, Stream data) { return this.PostFile(id, connection, arguments, filename, fileparameter, contentType, data); }

        /// <summary>Deletes a Graph API object.</summary>
        /// <param name="id">The identifier of the object that is being deleted.</param>
        /// <returns>The <see cref="String" /> or numeric identifier of the object that was deleted.</returns>
        public IdResult Delete(Object id)
        {
            return this.Delete(id, null);
        }

        /// <summary>Deletes a Graph API object.</summary>
        /// <param name="id">The identifier of the object that is being deleted.</param>
        /// <param name="connection">The optional name of the connection where the object is being deleted. This is
        /// currently only used for removing likes from a post.</param>
        /// <returns>The <see cref="String" /> or numeric identifier of the object that was deleted.</returns>
        protected virtual IdResult Delete(Object id, String connection)
        {
            var uri = CreateRequestUri(connection, null, id);
            var request = (HttpWebRequest)WebRequest.Create(uri);
            request.Method = "DELETE";

            var result = Execute<IdResult>(request);
            return result;
        }
        IdResult IFBGraphContext.Delete(Object id, String connection) { return this.Delete(id, connection); }

        /// <summary>Starts asynchronous execution and queueing of Graph API requests.</summary>
        /// <returns>A <see cref="AsyncContainer" /> instance that will contain a queue of Graph API requests and a collection of any
        /// errors that occur during the course of their execution.</returns>
        public virtual AsyncContainer StartAsync()
        {
            if (this.AsyncContainer == null)
            {
                this.AsyncContainer = new AsyncContainer(this);
                this.AsyncContainer.Disposed += new EventHandler(AsyncContainer_Disposed);
                return this.AsyncContainer;
            }
            else throw new InvalidOperationException("There is already an active AsyncContainer for this context. Call IFBGraphContext.CompleteAsync() before calling StartAsync() again.");
        }

        void AsyncContainer_Disposed(Object sender, EventArgs e)
        {
            this.AsyncContainer = null;
        }

        /// <summary>Blocks the current thread until all asynchronous requests have been completed.</summary>
        public virtual void CompleteAsync()
        {
            if (this.AsyncContainer != null)
            {
                AsyncContainer.Complete(this.AsyncContainer);
            }
            else throw new InvalidOperationException("There is no active AsyncContainer. Call IFBGraphContext.StartAsync() to start queuing asynchronous requests.");
        }

        /// <summary>Begins an asynchronous operation that waits for any queued requests to complete.</summary>
        /// <param name="callback">The <see cref="AsyncCallback" /> delegate.</param>
        /// <param name="state">The state object for the request.</param>
        /// <returns>An <see cref="IAsyncResult" /> that references the asynchronous completion request.</returns>
        public virtual IAsyncResult BeginCompleteAsync(AsyncCallback callback, Object state)
        {
            if (this.AsyncContainer != null) return this.AsyncContainer.BeginComplete(callback, state);
            else throw new InvalidOperationException("There is no active AsyncContainer. Call IFBGraphContext.StartAsync() to start queuing asynchronous requests.");
        }

        /// <summary>Ends an asychronous completion operation.</summary>
        /// <param name="result">The pending request to complete queued requests.</param>
        public virtual void EndCompleteAsync(IAsyncResult result)
        {
            if (this.AsyncContainer != null)
            {
                this.AsyncContainer.EndComplete(result);
            }
            else throw new InvalidOperationException("There is no active AsyncContainer. Call IFBGraphContext.StartAsync() to start queuing asynchronous requests.");
        }

        /// <summary>Gets a reference to the current <see cref="AsyncContainer" /> for the context.</summary>
        protected virtual AsyncContainer AsyncContainer { get; private set; }

        /// <summary>Generates a Graph API url for a given request with the current user's access token.</summary>
        /// <param name="connection">The optional name of a connection.</param>
        /// <param name="qs">An optional collection of name/value pairs to put on the querystring of the resulting uri, often used for filtering results.</param>
        /// <param name="ids">One or more Graph API identifier to retrieve data for.</param>
        /// <returns>A <see cref="Uri" /> used for querying the specified object(s), or the connection of an object.</returns>
        public virtual Uri CreateRequestUri(String connection, NameValueCollection qs, params Object[] ids)
        {
            var builder = new UriBuilder(FBGraphContext.API_URL);
            var path = new StringBuilder();
            var query = new StringBuilder();

            if (ids != null)
            {
                if (ids.Length == 1)
                {
                    path.Append(ids[0]);
                    if (!String.IsNullOrEmpty(connection)) path.AppendFormat("/{0}", connection);
                }
                else
                {
                    query.Append("ids=");
                    query.Append(String.Join(",", ids.Select(id => id.ToString()).ToArray()));
                }
            }
            else if (!String.IsNullOrEmpty(connection)) path.Append(connection);

            if (qs != null)
            {
                foreach (var key in qs.AllKeys)
                {
                    if (query.Length > 0) query.Append("&");
                    query.AppendFormat("{0}={1}", key, HttpUtility.UrlEncode((qs[key] ?? (Object)String.Empty).ToString()));
                }
            }

            if (this.IsAuthorized)
            {
                if (query.Length > 0) query.Append("&");
                query.AppendFormat("access_token={0}", this.AccessToken);
            }

            builder.Path = path.ToString();
            if (query.Length > 0) builder.Query = query.ToString();

            return builder.Uri;
        }

        internal Boolean HandleApiError(WebException ex, out FBGraphApiException fgex)
        {
            var handled = FBGraphApiHelper.HandleApiError(ex, out fgex);
            if(handled)
            {
                if(fgex.ErrorType == FBGraphApiErrorType.InvalidTokenError || fgex.ErrorType == FBGraphApiErrorType.TokenProcessingError) this.AccessToken = null;
                else throw fgex;
            }
            return handled;
        }
    }
}